終於來到倒數第二篇了,也可以說是名義上的最後一篇,前面講了很多圍繞在用戶註冊登入的相關知識,那今天就延續,寫寫用戶圖片上傳的檔案處理知識。
Multer 是一個 Node.js 中介軟體,專門用於處理 multipart/form-data 格式的表單數據,主要用於檔案上傳。它基於 busboy 構建,提供了簡潔的 API 來處理檔案。
req.file 或 req.files 中提供檔案資訊在 middlewares/upload.ts 建立上傳設定:
import multer from 'multer';
import path from 'path';
import fs from 'fs';
// 確保 uploads 資料夾存在
const uploadDir = path.join(__dirname, '../../uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 設定儲存方式
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname); // 取副檔名
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, `${file.fieldname}-${uniqueSuffix}${ext}`);
}
});
// 檔案過濾與大小限制
const upload = multer({
storage,
limits: {
fileSize: 2 * 1024 * 1024 // 限制 2MB
},
fileFilter: (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/jpg'];
if (!allowed.includes(file.mimetype)) {
return cb(new Error('僅允許上傳 JPEG 或 PNG 圖片'));
}
cb(null, true);
}
});
export const avatarUpload = upload.single('avatar');
Multer 提供兩種儲存方式:
const diskStorage = multer.diskStorage({
destination: (req, file, cb) => {
// 可以根據不同條件動態決定儲存位置
const userFolder = path.join(uploadDir, req.user?.id || 'anonymous');
if (!fs.existsSync(userFolder)) {
fs.mkdirSync(userFolder, { recursive: true });
}
cb(null, userFolder);
},
filename: (req, file, cb) => {
// 生成安全的檔案名稱
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
const uniqueName = `${Date.now()}-${sanitizedName}`;
cb(null, uniqueName);
}
});
優點:
缺點:
const memoryStorage = multer.memoryStorage();
const uploadToMemory = multer({
storage: memoryStorage,
limits: { fileSize: 1024 * 1024 } // 限制 1MB
});
// 在 controller 中處理
const handleUpload = async (req: Request, res: Response) => {
if (!req.file) {
return res.status(400).json({ error: '未上傳檔案' });
}
// req.file.buffer 包含檔案內容
const fileBuffer = req.file.buffer;
// 可以直接上傳到雲端服務 (S3, GCS 等)
await uploadToCloud(fileBuffer);
res.json({ message: '上傳成功' });
};
優點:
缺點:
更完善的檔案驗證:
// 更嚴格的 MIME 類型檢查
const strictFileFilter = (req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
// 1. 檢查 MIME 類型
const allowedMimes = ['image/jpeg', 'image/png', 'image/jpg', 'image/webp'];
// 2. 檢查副檔名
const ext = path.extname(file.originalname).toLowerCase();
const allowedExts = ['.jpg', '.jpeg', '.png', '.webp'];
if (!allowedMimes.includes(file.mimetype)) {
return cb(new Error(`不支援的檔案類型: ${file.mimetype}`));
}
if (!allowedExts.includes(ext)) {
return cb(new Error(`不支援的副檔名: ${ext}`));
}
cb(null, true);
};
// 根據檔案欄位名稱做不同驗證
const dynamicFileFilter = (req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
if (file.fieldname === 'avatar') {
const allowedMimes = ['image/jpeg', 'image/png'];
if (!allowedMimes.includes(file.mimetype)) {
return cb(new Error('大頭照只接受 JPEG 或 PNG 格式'));
}
} else if (file.fieldname === 'document') {
const allowedMimes = ['application/pdf'];
if (!allowedMimes.includes(file.mimetype)) {
return cb(new Error('文件只接受 PDF 格式'));
}
}
cb(null, true);
};
Multer 提供多種限制選項:
const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024, // 檔案大小限制 (5MB)
files: 10, // 最多上傳檔案數
fields: 20, // 最多表單欄位數
fieldSize: 100 * 1024, // 欄位值大小限制 (100KB)
fieldNameSize: 100, // 欄位名稱長度限制
headerPairs: 2000 // 最多 header 數量
}
});
// single(fieldname) - 上傳單一檔案
export const singleUpload = upload.single('avatar');
// 在 route 中使用
router.post('/upload/avatar', singleUpload, async (req: Request, res: Response) => {
if (!req.file) {
return res.status(400).json({ error: '未選擇檔案' });
}
// req.file 包含檔案資訊
const fileInfo = {
filename: req.file.filename,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size,
path: req.file.path
};
res.json({ message: '上傳成功', file: fileInfo });
});
// array(fieldname, maxCount) - 上傳多個檔案到同一欄位
export const multipleUpload = upload.array('photos', 5);
router.post('/upload/photos', multipleUpload, async (req: Request, res: Response) => {
if (!req.files || !Array.isArray(req.files)) {
return res.status(400).json({ error: '未選擇檔案' });
}
const filesInfo = req.files.map(file => ({
filename: file.filename,
originalname: file.originalname,
size: file.size
}));
res.json({
message: `成功上傳 ${req.files.length} 個檔案`,
files: filesInfo
});
});
// fields(fields) - 上傳多個檔案到不同欄位
export const mixedUpload = upload.fields([
{ name: 'avatar', maxCount: 1 },
{ name: 'gallery', maxCount: 5 },
{ name: 'document', maxCount: 1 }
]);
router.post('/upload/profile', mixedUpload, async (req: Request, res: Response) => {
const files = req.files as { [fieldname: string]: Express.Multer.File[] };
const avatar = files['avatar']?.[0];
const gallery = files['gallery'] || [];
const document = files['document']?.[0];
res.json({
avatar: avatar?.filename,
gallery: gallery.map(f => f.filename),
document: document?.filename
});
});
// any() - 接受任意欄位的檔案(不建議在生產環境使用)
export const anyUpload = upload.any();
router.post('/upload/any', anyUpload, async (req: Request, res: Response) => {
const files = req.files as Express.Multer.File[];
res.json({
message: `收到 ${files.length} 個檔案`,
files: files.map(f => ({ field: f.fieldname, name: f.filename }))
});
});
// middlewares/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import multer from 'multer';
export const uploadErrorHandler = (
err: any,
req: Request,
res: Response,
next: NextFunction
) => {
if (err instanceof multer.MulterError) {
// Multer 特定錯誤
switch (err.code) {
case 'LIMIT_FILE_SIZE':
return res.status(400).json({
error: '檔案大小超過限制',
message: '請上傳小於 2MB 的檔案'
});
case 'LIMIT_FILE_COUNT':
return res.status(400).json({
error: '檔案數量超過限制',
message: `最多只能上傳 ${err.field} 個檔案`
});
case 'LIMIT_UNEXPECTED_FILE':
return res.status(400).json({
error: '未預期的檔案欄位',
message: `欄位 ${err.field} 不被允許`
});
default:
return res.status(400).json({
error: 'Multer 錯誤',
message: err.message
});
}
}
if (err.message) {
// 自訂錯誤(來自 fileFilter)
return res.status(400).json({
error: '檔案驗證失敗',
message: err.message
});
}
next(err);
};
// routes/upload.routes.ts
import express from 'express';
import { avatarUpload } from '../middlewares/upload';
import { uploadErrorHandler } from '../middlewares/errorHandler';
const router = express.Router();
router.post('/upload/avatar',
avatarUpload,
uploadErrorHandler, // 錯誤處理中介層
async (req, res) => {
// 上傳成功的處理邏輯
res.json({ message: '上傳成功', file: req.file });
}
);
export default router;